iT邦幫忙

2024 iThome 鐵人賽

DAY 3
3
Modern Web

為你自己寫 Vue Component系列 第 3

[為你自己寫 Vue Component] AtomicButton

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicButton

按鈕在網頁中是最常見的元件之一,這個元件通常在使用者點擊後會觸發程式上的操作,可能是關閉或打開 Modal,也可能是送出表單,又或是刪除某些重要資料等等。

在 UI 表現上,按鈕會依照不同的情境而有不同的樣貌。主要按鈕通常會選用填色的實心按鈕,重要程度次之的可能會選用空心透明的按鈕,再次之的按鈕 UI 可能就會是像文字一樣的形式。

元件分析

元件架構

AtomicButton 元件架構

  1. Prepend:Button 前置 Icon。
  2. Text:Button 的文字內容。
  3. Append:Button 後置 Icon。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Button 元件設計。

Element Plus

Element Plus Button

<template>
  <ElButton>Default</ElButton>
  <ElButton type="primary">Primary</ElButton>
  <ElButton type="success">Success</ElButton>
  <ElButton type="info">Info</ElButton>
  <ElButton type="warning">Warning</ElButton>
  <ElButton type="danger">Danger</ElButton>
</template>

Element Plus 的 <ElButton> 提供了非常多樣化的 UI 變化設定,像是可以透過 type 來設定按鈕的顏色,plain 可以設定成 Element 定義的「樸素」按鈕,round 可以設定成圓角按鈕,circle 可以設定成圓形按鈕,size 可以調整按鈕的大小,icon 可以在按鈕前加入 Icon。

Vuetify

Vuetify Button

<template>
  <VBtn variant="elevated"> Button </VBtn>
  <VBtn variant="flat"> Button </VBtn>
  <VBtn variant="tonal"> Button </VBtn>
  <VBtn variant="outlined"> Button </VBtn>
  <VBtn variant="text"> Button </VBtn>
  <VBtn variant="plain"> Button </VBtn>
</template>

Vuetify 的 <VBtn> 提供了更多的樣式變化,像是 variant 可以設定成 elevatedflattonaloutlinedtextplain 等等,color 可以設定按鈕的顏色,elevation 的部分可以在 024 之間設定陰影的深度。

PrimeVue

PrimeVue Button

<template>
  <Button label="Primary" raised />
  <Button label="Secondary" severity="secondary" raised />
  <Button label="Success" severity="success" raised />
  <Button label="Info" severity="info" raised />
  <Button label="Warn" severity="warn" raised />
  <Button label="Help" severity="help" raised />
  <Button label="Danger" severity="danger" raised />
  <Button label="Contrast" severity="contrast" raised />
</template>

PrimeVue 的 <Button> 提供了 severity 來設定按鈕的顏色,raised 可以設定成有陰影的按鈕,rounded 可以將按鈕的外觀設定成圓角較大的按鈕,如果需要的是文字外觀的按鈕,則可以使用 text 做設定。

按鈕的功能很單純,但樣式變化卻非常豐富,幾乎所有的功能都落在 UI 的設定上。在 Element UI 與 PrimeVue 中,要設定按鈕的樣式都是透過特定的屬性來完成。

<!-- Element Plus -->
<ElButton type="primary" round>Primary</ElButton>
<ElButton type="primary" circle>Primary</ElButton>
<ElButton type="primary" text>Primary</ElButton>

<!-- PrimeVue -->
<Button label="Primary" raised />
<Button label="Primary" outlined />
<Button label="Primary" text />

不這些屬性如果同時出現,就要看那個設定的權重比較大了!

<!-- 這會長什麼樣子呢? -->
<ElButton type="primary" round circle />
<Button label="Primary" text outlined /> 

Vuetify 在這個部分比較不會有兩個外觀設定誰的權重比誰大的問題,因為它的設計是透過 variant 來設定按鈕的樣式,這樣一來就不會有兩個屬性的設定衝突問題。

綜合以上並結合自身經驗,我們統整出 <AtomicButton> 的功能:

  • 支援按鈕的基本屬性,像是 typedisabled 等等。
  • 可以透過 variant 設定按鈕的「樣式」,像是:實心按鈕(contained)、外框按鈕(outlined)與文字按鈕(text)。
  • 可以透過 color 設定按鈕的「顏色」,常見的有:primarysuccesswarningdangerinfo 等等。
  • 可以透過 shape 設定按鈕的「形狀」,預設為有圓角的長方形(rectangle)按鈕,其他還有 circlesquare 共三種模式。
  • 可以透過 size 依照需求設定按鈕的「大小」,像是:normalsmall
  • 可以在按鈕的前後加入 Icon。

使用結構如下:

<template>
  <AtomicButton
    variant="contained"
    color="primary"
    size="normal"
    type="button"
    disabled
  >
    按鈕
  </AtomicButton>
</template>

元件實作

首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:

屬性 型別 預設值 說明
variant contained, outlined, text contained 按鈕的樣式
color primary, success, warning, danger, info primary 按鈕的顏色
shape rectangle, circle, square rectangle 按鈕的形狀
size normal, small normal 按鈕的大小
type button, submit, reset button 原生按鈕的 type 設定
disabled boolean 按鈕是否禁用
interface AtomicButtonProps {
  type?: 'button' | 'submit' | 'reset'
  variant?: 'contained' | 'outlined' | 'text'
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
  size?: 'normal' | 'small'
  shape?: 'rectangle' | 'circle' | 'square'
  disabled?: boolean
}

const props = withDefaults(defineProps<AtomicButtonProps>(), {
  type: 'button',
  variant: 'contained',
  color: 'primary',
  size: 'normal',
})

<AtomicButton> 說起來,除了樣式非常多元外,它是一個實作上相當簡單的元件。

<template>
  <button
    class="atomic-button"
    :disabled="disabled"
    :type="type"
  >
    <span>
      <slot name="default" />
    </span>
  </button>
</template>

先給 <button> 一個基本的樣式,後面我們會透過 props 來設定按鈕的 UI 變化。

$name: '.atomic-button';

#{$name} {
  height: var(--button-size);
  font-size: 0.875rem;
  text-align: center;
  border-style: solid;
  border-width: 1px;
  border-color: transparent;
  outline: none;
  line-height: 1.25rem;
  transition-property: color, background-color, border-color;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-duration: 0.15s;

  &:disabled {
    cursor: not-allowed;
  }
}

這裡我們寫一個 computed 將 UI 設定添加到 <button> 上。

const rootClass = computed(() => {
  const BASIC_CLASS = 'atomic-button';
  return [
    `${BASIC_CLASS}--${props.variant}`,
    `${BASIC_CLASS}--${props.color}`,
    `${BASIC_CLASS}--${props.shape}`,
    `${BASIC_CLASS}--${props.size}`,
  ];
});

所以我們可以列出所有需要的 class 名稱

  • variant: atomic-button--contained, atomic-button--outlined, atomic-button--text
  • color: atomic-button--primary, atomic-button--success, atomic-button--warning, atomic-button--danger, atomic-button--info
  • shape: atomic-button--rectangle, atomic-button--circle, atomic-button--square
  • size: atomic-button--normal, atomic-button--small

這麼多樣式與組合,寫起來應該會很驚人吧!尤其是 colorvariant 的組合,總共就有 15 種組合,shapesize 的組合也有 6 種。

還好我們可以透過 SCSS 的 @each 與 CSS 變數來處理這些變化的組合。

variantcolor

這裡我們使用 SCSS 的 @each 來處理 variantcolor 的組合。

$name: '.atomic-button';

#{$name} {
  // variant
  &--contained {
    color: white;

    @each $color, $value in $color-map {
      &#{$name}--#{$color} {
        background-color: rgba($value, 1);

        &:not(:disabled):is(:hover, :focus) {
          background-color: rgba($value, 0.8);
        }

        &:not(:disabled):active {
          background-color: rgba($value, 0.6);
        }
      }
    }

    &:disabled {
      background: lightgray;
    }
  }
}

這樣一來我們就完成了 variantcontained 時的 5 種 color 組合,下面是其中一種組合的結果。

.atomic-button--contained {
  color: #fff;
}

.atomic-button--contained.atomic-button--primary {
  background-color: #1976d2;
}

.atomic-button--contained.atomic-button--primary:not(:disabled):is(
    :hover,
    :focus
  ) {
  background-color: #1976d2cc;
}

.atomic-button--contained.atomic-button--primary:not(:disabled):active {
  background-color: #1976d299;
}

$color-map 是一個我們自定義的 SCSS 變數,裡面包含了各種顏色的名稱與對應的色碼。

$color-map: (
  primary: #1976D2,
  success: #72BF24,
  warning: #FFAD0F,
  danger: #E52D27,
  info: #909399
);

sizeshape

這裡我們使用 CSS 的變數來處理 sizeshape 的組合。

$name: '.atomic-button';

#{$name} {
  height: var(--button-size);

  // size
  &--normal {
    --button-size: 36px;
    --button-padding: 20px;
  }

  &--small {
    --button-size: 32px;
    --button-padding: 10px;
  }

  // shape
  &--rectangle {
    padding-right: var(--button-padding);
    padding-left: var(--button-padding);
    border-radius: 6px;
  }

  &--square {
    width: var(--button-size);
    border-radius: 6px;
  }

  &--circle {
    width: var(--button-size);
    border-radius: 9999px;
  }
}

應用了 CSS 的變數功能,我們只在 size 的 class 設定好變數,而在 shape 的 class 裡面就可以直接使用這些變數。原本要寫六種組合的 CSS 現在只需要寫五種就好了,sizeshape 選擇越多,能省下的 CSS 就越多。

樣式搞定了,接下來我們處理 Icon 的部分。關於 Icon 我們有幾種設定方式可以考慮:

  • 透過 props 傳入 Icon 名稱。
  • 透過 props 傳入 Icon 元件。
  • 透過 slot 設定 Icon 內容。

使用 props 傳入 Icon 名稱的方式可以這樣做:

<AtomicButton icon="add">新增</AtomicButton>

這種做法在 <AtomicButton> 內部可以選擇使用 CSS 實作或是依照傳入的屬性去取得對應的 Icon 元件。前者我們需要在元件內部或另外維護一包 Icon 的 CSS,後者則是需要建立名稱與元件的對應表。

如果能讓 Icon 的設定與 <AtomicButton> 脫鉤那就更好了。這樣就不需要在元件內部維護 Icon 的 CSS 或建立對應表了。

使用 props 傳入 Icon 元件的方式可以這樣做:

<template>
  <AtomicButton :icon="AddSvg">新增</AtomicButton>
</template>

這裏使用 vite-svg-loader 來載入 SVG 這樣我們就可以直接將 SVG 當作元件來使用。

這樣一來我們就讓 Icon 與 <AtomicButton> 完全解耦,而且這樣的設計讓我們可以更容易地擴充 Icon。但有些人可能覺得如果想要調整 Icon 的樣式就會變得比較麻煩,這時候我們可以再傳入 iconProps 作爲 Icon 元件的 props,或是我們還有其他選擇。

透過 slot 設定 Icon 的話我們可以這樣做:

<template>
  <AtomicButton>
    <template #prepend>
      <AddSvg fill="currentColor" />
    </template>

    新增
  </AtomicButton>
</template>

這樣一來我們就能夠更自由地設定 Icon 與其樣式,使用起來更加彈性與方便,缺點則是使用上可能會讓畫面稍微雜亂一點。

我自己偏好使用 slot 做設定,雖然使用上會略顯雜亂,但卻是最彈性的方式。

<template>
  <button
    class="atomic-button"
    :class="rootClass"
    :disabled="disabled"
    :type="type"
  >
    <span v-if="$slots.prepend">
      <slot name="prepend" />
    </span>
    <span>
      <slot name="default" />
    </span>
    <span v-if="$slots.append">
      <slot name="append" />
    </span>
  </button>
</template>

這裏我們還加上了 named slot 是否有被使用的判斷,像是上面的範例有使用到 prepend slot,我們才把該層的 <span> 給渲染出來。這一來可以避免渲染無用的結構,還可以幫助我們更方便地定義按鈕內元素的間距。

$name: '.atomic-button';

#{$name} {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  column-gap: 10px;
}

這樣一來我們就完成了 <AtomicButton> 元件,現在可以依照不同的場景任意切換按鈕的樣式了。

AtomicButton 完成

進階功能

我們經常看到一些 UI 呈現上與按鈕一樣,但實際上點擊後會像超連結一樣換頁,當然我們可以使用 router.push() 來完成這個功能,但如果考量到語意化標籤的使用、無障礙或是 SEO 的考量,<AtomicButton> 元件如果能支援渲染成 <a> 標籤那就更好了。

所以我們讓 <AtomicButton> 也可以接收 to 這個屬性,在使用時沒有傳入 to 的話就維持使用 <button>,反之有傳入 to 時則改用 <AtomicLink>

interface AtomicButtonProps {
  // 如果有傳入 `to`,內部會渲染 `<AtomicLink :to="to" />`
  to?: RouteLocationRaw
}

const props = withDefaults(defineProps<AtomicButtonProps>(), {
  // 略
  to: undefined,
})

const rootComponent = computed(() => {
  return props.to == null ? 'button' : AtomicLink
})
<template>
  <component
    :is="rootComponent"
    class="atomic-button"
    :class="rootClass"
    :disabled="rootComponent === 'button' ? disabled : undefined"
    :to="to"
    :type="rootComponent === 'button' ? type : undefined"
  >
    <!-- 略 -->
  </component>
</template>

這樣一來我們就可以在按鈕與連結之間切換,卻仍然擁有相同的 UI 表現了!

<a> 元素不支援 disabled,所以在這裡只有當 rootComponent 為 button 時才加上 disabled 的設定。如果是渲染成 <AtomicLink> 則永遠傳入 undefined

無障礙

很多人習慣使用 <div><span> 並在上面綁定點擊事件作為按鈕使用,因為這樣不用處理跨瀏覽器上的按鈕樣式不統一問題。這乍看之下沒有什麼問題,一樣可以點擊、一樣可以結案,但其實使用這種方式做成的按鈕功能並不完整。

首先,當滑鼠滑到原生按鈕上方時,游標會從箭頭變成「手指頭」。另外,原生的按鈕元件除了可以點擊之外,還可以透過 tab 鍵做焦點的切換,並且可以透過按下 space 跟 enter 鍵觸發 click 事件。在無障礙方面,螢幕閱讀器會告訴使用者這是一個可以點擊(或是禁用)的按鈕。

所以在使用上能選用 <button> 作為按鈕時就盡量不要使用其他的元素替代。如有需要使用像是 <div> 等其他元素替代時,則也需要讓該元素符合上述的各種條件。

以下是如果需要用非 <button> 元素實作按鈕功能建議要加上的屬性,如果有需要可以作為參考使用。

<template>
  <div
    role="button"
    tabindex="0"
    @click="onButtonClick"
    @keydown="onButtonKeydown"
  >
    我是一個按鈕
  </div>
</template>

另外順帶一提,與 <button> 元素不同,<a> 元素只支援 enter 觸發 click 事件,在實作上不妨多留意兩者之間些微的不同。

總結

<AtomicButton> 我們幾乎沒有處理關於「功能」方面的程式碼,而是聚焦在如何處理各種變化組合的按鈕。也因為我們是繼承 HTML 中的 <button> 元素,因此按鈕本身可以支援的屬性,元件也都支援。

我們唯一處理的功能是在傳入 to 的時候會自動切換使用 <AtomicLink> 但擁有一樣的 UI 樣式。在遇到長得像按鈕的連結時其實非常好用。

無障礙部分提供給需要用其他元素替代原生按鈕元素的讀者參考。按鈕是網頁開發中最常用的元件之一,雖然很簡單實作,但也是有一些小細節需要注意。如果能把無障礙的部分也照顧好,才能稱得上是使用者友善的網站吧!

參考資料


上一篇
[為你自己寫 Vue Component] AtomicLink
下一篇
[為你自己寫 Vue Component] AtomicBreadcrumb
系列文
為你自己寫 Vue Component19
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言